S03-04 JS-高级-JS运行原理、JS内存管理、闭包、函数增强
[TOC]
JS运行原理@
V8引擎原理
JS代码的执行
JavaScript 代码下载好之后,是如何一步步被执行的呢?
浏览器内核组成:我们知道,浏览器内核 是由两部分组成的,以 webkit 为例:
WebCore:负责 HTML 解析、布局、渲染等等相关的工作;
JavaScriptCore:解析、执行 JavaScript 代码;
另外一个强大的 JavaScript 引擎就是 V8 引擎。
V8引擎-执行原理
V8:我们来看一下官方对 V8 引擎的定义:
V8 是用 C ++编写的 Google 开源高性能 JavaScript 和 WebAssembly 引擎,它用于Chrome和Node.js等。
它实现ECMAScript和WebAssembly规范,并在 Windows 7 或更高版本,macOS 10.12+和使用 x64,IA-32,ARM 或 MIPS 处理器的 Linux 系统上运行。
V8 可以独立运行,也可以嵌入到任何 C++ 应用程序中。
WebAssembly:是一种二进制指令格式,专为Web设计的低级编程语言,可在现代浏览器中高性能执行。它不是替代 JavaScript,而是作为其补充,用于处理需要接近原生性能的任务(如游戏、音视频处理、科学计算等)。
总结: 高性能、跨平台、可独立运行
V8引擎-架构
V8 引擎本身的源码非常复杂,大概有超过100w 行 C++代码,通过了解它的架构,我们可以知道它是如何对 JavaScript 执行的:
Parse:模块会将 JavaScript 代码转换成 AST(抽象语法树),这是因为解释器并不直接认识 JavaScript 代码;
如果函数没有被调用,那么是不会被转换成 AST 的;
Parse 的 V8 官方文档:https://v8.dev/blog/scanner
Ignition:是一个解释器,会将 AST 转换成 字节码(ByteCode)
同时会收集 TurboFan 优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算);
如果函数只调用一次,Ignition 会解释执行 ByteCode;
Ignition 的 V8 官方文档:https://v8.dev/blog/ignition-interpreter
TurboFan:是一个优化编译器,可以将字节码编译为 CPU 可以直接执行的机器码(MachineCode);
如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过 TurboFan转换成优化的机器码,提高代码的执行性能;
但是,机器码实际上也会被去优化(Deoptimization)为 ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如 sum 函数原来执行的是 number 类型,后来执行变成了 string 类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码;
TurboFan 的 V8 官方文档:https://v8.dev/blog/turbofan-jit
相关概念:
- 抽象语法树(Abstract Syntax Tree, AST)
- 字节码(ByteCode):是介于高级编程语言与机器码之间的中间代码,本质是一套虚拟指令集,需由虚拟机(VM)或运行时环境解释/编译执行。它是现代编程语言实现跨平台、高效执行的核心技术方案。
- 机器码(MachineCode):是计算机CPU可直接执行的底层指令,由二进制数字(0和1)组成,直接对应处理器的硬件操作。它是所有软件运行的最终形态,是连接软件与硬件的终极桥梁。
抽象语法树
抽象语法树(Abstract Syntax Tree, AST):是计算机科学中用于表示代码结构的树状数据结构,它是代码的抽象化表达,去除了不重要的语法细节(如分号、括号位置等),只保留代码的逻辑结构和语义关系,是编译器、解释器、代码分析工具的核心中间表示形式。
关键特性:
- 结构化表示:用树形结构分层表达代码逻辑(如函数、循环、表达式等)
- 去语法糖:忽略具体语法符号(如
{}
、;
等),保留核心逻辑 - 语言无关性:不同编程语言的AST结构可能相似,便于跨语言分析
- 可遍历性:通过深度优先搜索(DFS)等算法遍历节点
AST生成过程:
以JavaScript代码 const sum = (a, b) => a + b;
为例:
词法分析(Lexical Analysis) 将代码拆分为词法单元(Tokens):
json[ { type: 'Keyword', value: 'const' }, { type: 'Identifier', value: 'sum' }, { type: 'Punctuator', value: '=' }, { type: 'Punctuator', value: '(' }, { type: 'Identifier', value: 'a' }, // ... 其他tokens ]
语法分析(Syntax Analysis) 根据语法规则将Tokens转换为AST:
json{ "type": "VariableDeclaration", "declarations": [{ "type": "VariableDeclarator", "id": { "type": "Identifier", "name": "sum" }, "init": { "type": "ArrowFunctionExpression", "params": [ { "type": "Identifier", "name": "a" }, { "type": "Identifier", "name": "b" } ], "body": { "type": "BinaryExpression", "operator": "+", "left": { "type": "Identifier", "name": "a" }, "right": { "type": "Identifier", "name": "b" } } } }], "kind": "const" }
AST核心节点类型:
节点类型 | 描述 | 示例代码片段 |
---|---|---|
Program | 整个程序的根节点 | 任何完整代码 |
VariableDeclaration | 变量声明 | let x = 10; |
FunctionDeclaration | 函数声明 | function foo() {} |
IfStatement | 条件语句 | if (condition) {...} |
ForStatement | for循环 | for (let i=0; i<5; i++) |
BinaryExpression | 二元运算表达式 | a + b |
CallExpression | 函数调用 | console.log() |
Literal | 字面量 | 3 |
应用场景:
代码编译/转译
- Babel将ES6+代码转换为ES5(通过AST分析修改)
- TypeScript编译器检查类型错误
js// Babel处理流程 源代码 → AST → 插件修改AST → 生成新代码
代码静态分析
- ESLint检查代码规范
- Webpack进行依赖分析
js// ESLint规则示例:禁止console if (node.type === 'CallExpression' && node.callee.object?.name === 'console') { reportError('禁止使用console'); }
代码格式化
- Prettier通过AST重新生成标准化代码
- 自动修复工具(如VS Code的快速修复)
代码混淆/压缩
- 变量重命名(
longVariableName → a
) - 删除未使用代码(Tree Shaking)
- 变量重命名(
智能开发工具
- 代码自动补全(分析上下文AST)
- 重构工具(如提取函数、变量重命名)
对比具体语法树(CST):
对比维度 | AST(抽象语法树) | CST(具体语法树) |
---|---|---|
节点内容 | 只保留关键逻辑节点 | 包含所有语法细节(如标点符号、括号) |
存储空间 | 较小 | 较大 |
使用场景 | 编译器优化、代码转换 | 语法高亮、代码格式化 |
示例对比 | a + b → BinaryExpression | a + b → Identifier(+)Identifier |
示例:AST修改实践
将代码 let x = 1 + 2;
优化为 let x = 3;
:
// 原始AST片段
{
type: 'VariableDeclarator',
id: { type: 'Identifier', name: 'x' },
init: {
type: 'BinaryExpression',
operator: '+',
left: { type: 'Literal', value: 1 },
right: { type: 'Literal', value: 2 }
}
}
// 修改后的AST
{
type: 'VariableDeclarator',
id: { type: 'Identifier', name: 'x' },
init: { type: 'Literal', value: 3 }
}
作用:
- 解耦语法与逻辑:同一逻辑的不同语法写法可生成相同AST(如
a+b
与a + b
) - 简化处理:避免直接操作字符串的复杂性
- 跨平台能力:不同工具链通过AST交换代码信息
- 性能优化:基于AST的静态分析比运行时分析更高效
V8 引擎-Parse过程
Parse流程:
源码传递与编码转换
Blink
(浏览器渲染引擎)将JavaScript源码传递给V8引擎,由Stream
模块处理源码并进行编码转换(如UTF-8解码)。词法分析
Scanner
对源码进行词法分析,将字符流转换为Tokens
。- 例如:
var a = 1;
→ 分解为var
、a
、=
、1
、;
等Token。
- 例如:
语法分析生成AST
Tokens通过语法分析转换为抽象语法树(AST),分为两种解析方式:
- Parser(全量解析):直接将Tokens转换为完整的AST。
- PreParser(预解析):仅部分解析,生成简化版AST,用于快速检查和优化。
后续流程
- AST生成后,由V8的
Ignition
解释器转换为字节码并执行。 - 字节码可通过
TurboFan
编译器进一步优化为机器码,提高执行效率(属代码执行阶段,非解析过程)。
- AST生成后,由V8的
PreParser的作用与原因:
核心目的:性能优化
- 减少初始解析时间:并非所有代码都需要立即执行(如未调用的函数),避免全量解析提升加载效率。
- 语法错误预检:快速识别语法错误,无需等待执行阶段。
延迟解析(Lazy Parsing)
对暂未执行的函数(如嵌套函数)进行预解析,仅提取关键信息(参数、函数体位置等)。
jsfunction outer() { function inner() { /* 预解析 */ } }
inner
函数在outer
调用前仅预解析,全量解析推迟到inner
实际调用时。
资源节省
- 避免生成完整AST和字节码,减少内存占用和CPU消耗。
JS执行上下文
ECMA版本说明
在 ECMA 早期的版本中(ECMAScript3),代码的执行流程的术语和 ECMAScript5 以及之后的术语会有所区别:
目前网上大多数流行的说法都是基于ECMAScript3版本的解析,并且在面试时问到的大多数都是 ECMAScript3 的版本内容。
但是 ECMAScript3 终将过去, ECMAScript5必然会成为主流,所以最好也理解 ECMAScript5 甚至包括ECMAScript6 以及更好版本的内容;
事实上在TC39的最新描述中,和 ECMAScript5 之后的版本又出现了一定的差异;
那么我们课程按照如下顺序学习:
通过ECMAScript3中的概念学习JavaScript 执行原理、作用域、作用域链、闭包等概念;
通过ECMAScript5中的概念学习块级作用域、let、const等概念;
事实上,它们只是在对某些概念上的描述不太一样,在整体思路上都是一致的。
JS执行原理
假如我们有下面一段代码,它在 JavaScript 中是如何被执行的呢?
JS执行流程
1、初始化全局对象 GO
2、事先存在一个执行上下文栈 ECS
3、执行全局代码:
在 ECS 中创建一个全局执行上下文 GEC
在 GEC 中创建 VO 对象,让它关联到 GO 对象
变量的作用域提升:在转成 AST 树时,会将变量、函数加入到 GO 中,但不赋值
4、执行函数代码:
- 在 ECS 中创建一个函数执行上下文 FEC
- 在 FEC 中创建 VO 对象,让它关联到 AO 对象
- 变量的作用域提升:在转成 AST 树时,会将变量、函数加入到 AO 中,但不赋值
JS执行-初始化全局对象GO
GO 对象:JS 引擎会在执行代码之前,在堆内存中创建一个全局对象 GO(Global Object)
该对象 所有的作用域(scope)都可以访问,在浏览器中该对象就是window;
里面会包含 Date、Array、String、Number、setTimeout、setInterval 等等;
其中还有一个 window 属性指向自己;
JS执行-执行上下文EC
JS 引擎内部有一个执行上下文栈 ECS(Execution Context Stack),它是用于执行代码的调用栈。
那么现在它要执行谁呢?执行的是全局的代码块:
全局的代码块为了执行会构建一个 全局执行上下文 GEC(Global Execution Context);
GEC 会 被放入到 ECS 中 执行;
GEC 被放入到 ECS 中里面包含两部分内容:
第一部分:作用域提升,在代码执行前,在 parser 转成 AST 的过程中,会将全局定义的变量、函数等加入到 GlobalObject 中,但是并不会赋值;这个过程也称之为变量的作用域提升(hoisting)
第二部分:在代码执行中,对变量赋值,或者执行其他的函数;
JS执行-认识VO对象
每一个执行上下文会关联一个变量对象 VO(Variable Object),变量和函数声明会被添加到这个 VO 对象中。
当全局代码被执行的时候,VO 就是 GO 对象了
全局代码执行过程
全局代码执行过程(执行前)
全局代码执行过程(执行后)
函数代码执行过程
函数如何被执行呢?
在执行的过程中执行到一个函数时,就会根据函数体创建一个函数执行上下文 FEC(Functional Execution Context),并且压入到 EC Stack 中。
因为每个执行上下文都会关联一个 VO,那么函数执行上下文关联的 VO 是什么呢?
当进入一个函数执行上下文时,会创建一个AO 对象(Activation Object);
这个 AO 对象会使用arguments作为初始化,并且初始值是传入的参数;
这个 AO 对象会作为执行上下文的 VO 来存放变量的初始化;
函数的执行过程(执行前)
函数的执行过程(执行后)
函数的多次执行
函数代码相互调用
作用域和作用域链
全局变量的查找
函数代码变量的查找
1、函数中有定义自己的 message
2、函数中没有自己的 message
作用域和作用域链
当进入到一个执行上下文时,执行上下文也会关联一个作用域链(Scope Chain)
作用域链是一个对象列表,用于变量标识符的求值;
当进入一个执行上下文时,这个作用域链被创建,并且根据代码类型,添加一系列的对象;
函数的作用域链和函数的定义位置有关,与调用位置无关
多层嵌套函数的作用域链
作用域提升面试题
JS内存管理@
JS内存管理
认识内存管理
不管什么样的编程语言,在代码的执行过程中都是需要给它分配内存的,不同的是某些编程语言需要我们自己手动的管理内存,某些编程语言会可以自动帮助我们管理内存:
内存管理的生命周期:不管以什么样的方式来管理内存,内存的管理都会有如下的生命周期:
申请:分配申请你需要的内存;
使用:使用分配的内存(存放一些东西,比如对象等);
释放:不需要使用时,对其进行释放;
不同的编程语言对于第一步和第三步会有不同的实现:
手动管理内存:比如 C、C++,包括早期的 OC,都是需要手动来管理内存的申请和释放的(malloc 和 free 函数);
自动管理内存:比如 Java、JavaScript、Python、Swift、Dart 等,它们有自动帮助我们管理内存;
对于开发者来说,JavaScript 的内存管理是自动的、无形的。
我们创建的原始值、对象、函数……这一切都会占用内存;
但是我们并不需要手动来对它们进行管理,JavaScript 引擎会帮助我们处理好它;
JS的内存管理
JavaScript 会在定义数据时为我们分配内存。
但是内存分配方式是一样的吗?
JS 对于原始数据类型内存的分配会在执行时,直接在栈空间进行分配;
JS 对于复杂数据类型内存的分配会在堆内存中开辟一块空间,并且将这块空间的指针返回值变量引用;
垃圾回收机制算法
JS的垃圾回收
垃圾回收机制(Garbage Collection, GC):是编程语言中自动管理内存的核心机制,负责识别和释放程序中不再使用的内存(即“垃圾”),从而避免内存泄漏,减轻开发者手动管理内存的负担。
因为内存的大小是有限的,所以当内存不再需要的时候,我们需要对其进行释放,以便腾出更多的内存空间。
手动管理内存的缺点:在手动管理内存的语言中,我们需要通过一些方式自己来释放不再需要的内存,比如 free 函数:
但是这种管理的方式其实非常的低效,影响我们编写逻辑的代码的效率;
并且这种方式对开发者的要求也很高,并且一不小心就会产生内存泄露(Memory Leaks)、野指针(Dangling Pointers);
所以大部分现代的编程语言都是有自己的垃圾回收机制:
垃圾回收的英文是Garbage Collection,简称GC;
对于那些不再使用的对象,我们都称之为是垃圾,它需要被回收,以释放更多的内存空间;
而我们的语言运行环境,比如 Java 的运行环境 JVM,JavaScript 的运行环境 js 引擎都会内存垃圾回收器;
垃圾回收器我们也会简称为GC,所以在很多地方你看到 GC 其实指的是垃圾回收器;
但是这里又出现了另外一个很关键的问题:GC 怎么知道哪些对象是不再使用的呢?
- 这里就要用到 GC 的实现以及对应的算法;
常见GC算法-引用计数
引用计数(Reference Counting):当一个对象有一个引用指向它时,那么这个对象的引用就+1;如果一个变量停止引用该对象,引用计数-1;当一个对象的引用为 0 时,这个对象就可以被销毁掉;
弊端:这个算法有一个很大的弊端就是会产生循环引用;
解决方案:
方案一:
obj1.info=null
当
obj1=null
和obj2=null
时,依然会有obj1.info
指向当前对象,引用计数为 1,所以无法销毁必须通过obj1.info=null
才能取消引用方案二:使用WeakMap弱引用
常见GC算法-标记清除
标记清除(Mark-Sweep):是垃圾回收中最经典的算法之一,通过可达性(Reachability)分析识别并回收内存中的无用对象。其核心思想是设置一个根对象(Root Object),垃圾回收器会定期从这个根开始,找所有从根开始有引用到的对象,对于那些没有引用到的对象,就认为是不可用的对象。
优缺点:
优势 | 劣势 |
---|---|
✅ 解决循环引用问题(如 A→B→A ) | ⚠️ 产生内存碎片(需额外整理步骤) |
✅ 无需维护引用计数开销 | ⚠️ 全堆扫描,执行效率较低 |
✅ 实现相对简单 | ⚠️ 触发时需暂停主线程(Stop-The-World) |
应用:V8 使用的是该算法
算法流程:
标记阶段(Marking)
- 步骤:
- 确定根对象(Roots):全局变量(如
window
)、当前执行栈中的变量(局部变量、参数)、被引用的活动对象。 - 深度优先遍历:从根对象出发,递归遍历所有被引用的子对象,标记为“存活”。
- 确定根对象(Roots):全局变量(如
- 实现方式:
- 在对象头中添加标记位(如
marked: true/false
)。 - 使用三色标记法(白→灰→黑)优化增量标记。
- 在对象头中添加标记位(如
- 步骤:
清除阶段(Sweeping)
- 步骤:
- 线性扫描整个堆内存。
- 释放所有未被标记的对象所占内存。
- 重置存活对象的标记位(为下次GC准备)。
- 内存处理:
- 简单释放:将空闲内存块加入空闲列表(Free List)。
- 合并相邻空闲块:减少内存碎片。
- 步骤:
常见GC算法-其他算法优化补充
JS 引擎比较广泛的采用的就是可达性中的标记清除算法,当然类似于 V8 引擎为了进行更好的优化,它在算法的实现细节上也会结合一些其他的算法。
标记整理(Mark-Compact): 是垃圾回收中的一种优化策略,旨在解决标记-清除算法导致的内存碎片问题。它在标记存活对象后,通过移动存活对象位置实现内存空间的连续化,从而提升内存利用率。
分代收集(Generational Garbage Collection):对象被分成两组:“新的”和“旧的”。
许多对象出现,完成它们的工作并很快死去,它们可以很快被清理;
那些长期存活的对象会变得“老旧”,而且被检查的频次也会减少;
增量收集(Incremental Collection):
如果有许多对象,并且我们试图一次遍历并标记整个对象集,则可能需要一些时间,并在执行过程中带来明显的延迟。
所以引擎试图将垃圾收集工作分成几部分来做,然后将这几部分会逐一进行处理,这样会有许多微小的延迟而不是一个大的延迟;
闲时收集(Idle-time Collection):
- 垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响。
V8引擎详细的内存图
事实上,V8 引擎为了提供内存的管理效率,对内存进行非常详细的划分:
新生代空间 (New Space / Young Generation)
- 作用:主要用于存放生命周期短的小对象。这部分空间较小,但对象的创建和销毁都非常频繁。
- 组成:新生代内存被分为两个半空间:From Space 和 To Space。
- 初始时,对象被分配到 From Space 中。
- 使用复制算法(Copying Garbage Collection)进行垃圾回收。
- 当进行垃圾回收时,活动的对象(即仍然被引用的对象)被复制到 To Space 中,而非活动的对象(不再被引用的对象)被丢弃。
- 完成复制后,From Space 和 To Space 的角色互换,新的对象将分配到新的 From Space 中,原 To Space 成为新的 From Space。
老生代空间(Old Space / Old Generation)
- 作用:存放生命周期长或从新生代晋升过来的对象。
- 当对象在新生代中经历了一定数量的垃圾回收周期后(通常是一到两次),且仍然存活,它们被认为是生命周期较长的对象。
- 分为二个主要区域:
- 老指针空间(Old Pointer Space):主要存放包含指向其他对象的指针的对象。
- 老数据空间(Old Data Space):用于存放只包含原始数据(如数值、字符串)的对象,不含指向其他对象的指针。
大对象空间(Large Object Space):用于存放大对象,如超过新生代大小限制的数组或对象。
- 这些对象直接在大对象空间中分配,避免在新生代和老生代之间的复制操作。
代码空间(Code Space):存放编译后的函数代码。
单元空间(Cell Space):用于存放小的数据结构,比如闭包的变量环境。
属性单元空间(Property Cell Space):存放对象的属性值。
- 主要针对全局变量或者属性值,对于访问频繁的全局变量或者属性值来说,V8在这里存储是为了提高它的访问效率。
映射空间(Map Space):存放对象的映射(即对象的类型信息,描述对象的结构)。
- 当你定义一个 Person 构造函数时,可以通过它创建出来person1和person2。
- 这些实例(person1 和 person2)本身存储在堆内存的相应空间中,具体是新生代还是老生代取决于它们的生命周期和大小。
- 每个实例都会持有一个指向其映射的指针,这个映射指明了如何访问 name 和 age 属性(目的是访问属性效果变高)。
堆内存(Heap Memory)与栈 (Stack)
- 堆内存:JavaScript 对象、字符串等数据存放的区域,按照上述分类进行管理。
- 栈:用于存放执行上下文中的变量、函数调用的返回地址(继续执行哪里的代码)等,栈有助于跟踪函数调用的顺序和局部变量。
闭包@
闭包-概念
又爱又恨的闭包
闭包是 JavaScript 中一个非常容易让人迷惑的知识点:
有同学在深入 JS 高级的交流群中发了这么一张图片;
并且闭包也是群里面大家讨论最多的一个话题;
闭包确实是 JavaScript 中一个很难理解的知识点,接下来我们就对其一步步来进行剖析,看看它到底有什么神奇之处。
JS的函数式编程
在前面我们说过,JavaScript 是支持函数式编程的
在 JavaScript 中,函数是非常重要的,并且是一等公民:
那么就意味着函数的使用是非常灵活的;
函数可以作为另外一个函数的参数,也可以作为另外一个函数的返回值来使用;
所以 JavaScript 存在很多的高阶函数:
自己编写高阶函数
使用内置的高阶函数
目前在 vue3 和 react 开发中,也都在趋向于函数式编程:
vue3 composition api: setup 函数 -> 代码(函数 hook,定义函数);
react:class -> function -> hooks
闭包的定义
这里先来看一下闭包的定义,分成两个:在计算机科学中和在 JavaScript 中。
维基百科定义:在计算机科学中对闭包的定义(维基百科):
闭包(Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures);
是在支持头等函数的编程语言中,实现词法绑定的一种技术;
闭包在实现上是一个结构体,它存储了一个函数和一个关联的环境(相当于一个符号查找表);
闭包跟函数最大的区别在于,当捕捉闭包的时候,它的自由变量会在捕捉时被确定,这样即使脱离了捕捉时的上下文,它也能照常运行;
历史:闭包的概念出现于 60 年代,最早实现闭包的程序是 Scheme,那么我们就可以理解为什么 JavaScript 中有闭包:
- 因为 JavaScript 中有大量的设计是来源于 Scheme 的;
MDN定义:我们再来看一下MDN对 JavaScript 闭包的解释:
一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure);
也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域;
在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来;
自己总结:那么我的理解和总结:
一个普通的函数function,如果它可以访问外层作用域的自由变量,那么这个函数和周围环境就是一个闭包;
从广义的角度来说:JavaScript 中的函数都是闭包;
从狭义的角度来说:JavaScript 中一个函数,如果访问了外层作用域的变量,那么它是一个闭包;
闭包-形成过程
闭包的访问过程
如果我们编写了如下的代码,它一定是形成了闭包的:
闭包的执行过程
那么函数继续执行呢?
这个时候 makeAdder 函数执行完毕,正常情况下我们的 AO 对象会被释放;
但是因为在 0xb00 的函数中有作用域引用指向了这个 AO 对象,所以它不会被释放掉;
闭包-内存泄露
闭包的内存泄漏
那么我们为什么经常会说闭包是有内存泄露的呢?
在上面的案例中,如果后续我们不再使用 add10 函数了,那么该函数对象应该要被销毁掉,并且其引用着的父作用域 AO 也应该被销毁掉;
但是目前因为在全局作用域下 add10 变量对 0xb00 的函数对象有引用,而 0xb00 的作用域中 AO(0x200)有引用,所以最终会造成这些内存都是无法被释放的;
所以我们经常说的闭包会造成内存泄露,其实就是刚才的引用链中的所有对象都是无法释放的;
那么,怎么解决这个问题呢?
因为当手动将 add10 设置为 null时,就不再对函数对象 0xb00 有引用,那么对应的 AO 对象 0x200 也就不可达了;
在 GC 的下一次检测中,它们就会被销毁掉;
闭包的内存泄漏测试
AO不使用的属性优化
我们来研究一个问题:AO 对象不会被销毁时,是否里面的所有属性都不会被释放?
下面这段代码中 name 属于闭包的父作用域里面的变量;
我们知道形成闭包之后 count 一定不会被销毁掉,那么 name 是否会被销毁掉呢?
这里我打上了断点,我们可以在浏览器上看看结果;
函数增强
函数属性、arguments
函数对象的属性
我们知道 JavaScript 中函数也是一个对象,那么对象中就可以有属性和方法。
1、自定义函数属性
2、函数内置属性
- name:一个函数的名词我们可以通过 name 来访问;
- length:属性 length 用于返回函数形参的个数;
注意: rest 参数和有默认值的参数是不参与参数的个数的;
认识arguments
arguments是一个对应于传递给函数的参数的类数组(array-like)对象。
array-like 意味着它不是一个数组类型,而是一个对象类型:
但是它却拥有数组的一些特性,比如说 length,比如可以通过 index 索引来访问;
但是它却没有数组的一些方法,比如 filter、map 等;
arguments转Array
在开发中,我们经常需要将 arguments 转成 Array,以便使用数组的一些特性。
常见的转化方式如下:
*转化方式一:*遍历 arguments,添加到一个新数组中;
转化方式二: 调用数组 slice 函数的 call 方法;较难理解(有点绕),了解即可
*转化方式三:*ES6 中的两个方法
Array.from(arguments)
[…arguments]
箭头函数不绑定arguments
箭头函数是不绑定 arguments 的,所以我们在箭头函数中使用 arguments 会去上层作用域查找:
1、箭头函数不绑定 arguments
2、在箭头函数中使用 arguments 会去上层作用域查找
函数的剩余(rest)参数
ES6 中引用了剩余参数(rest parameter),可以将不定数量的参数放入到一个数组中:
- 如果最后一个参数是 ... 为前缀的,那么它会将剩余的参数放到该参数中,并且作为一个数组;
那么剩余参数和arguments有什么区别呢?
剩余参数只包含那些没有对应形参的实参,而 arguments 对象包含了传给函数的所有实参;
arguments对象不是一个真正的数组,而rest 参数是一个真正的数组,可以进行数组的所有操作;
arguments 是早期的 ECMAScript 中为了方便去获取所有的参数提供的一个数据结构,而 rest 参数是 ES6 中提供并且希望以此来替代 arguments 的;
剩余参数必须放到最后一个位置,否则会报错。
纯函数
理解JS纯函数
函数式编程中有一个非常重要的概念叫纯函数(Pure Function),JavaScript 符合函数式编程的范式,所以也有纯函数的概念;
在react开发中纯函数是被多次提及的;
比如react 中组件就被要求像是一个纯函数(为什么是像,因为还有 class 组件),redux 中有一个 reducer 的概念,也是要求必须是一个纯函数;
所以掌握纯函数对于理解很多框架的设计是非常有帮助的;
纯函数的维基百科定义:
在程序设计中,若一个函数符合以下条件,那么这个函数被称为纯函数:
此函数在相同的输入值时,需产生相同的输出。
函数的输出和输入值以外的其他隐藏信息或状态无关,也和由 I/O 设备产生的外部输出无关。
该函数不能有语义上可观察的函数副作用,诸如“触发事件”,使输出设备输出,或更改输出值以外物件的内容等。
当然上面的定义会过于的晦涩,所以我简单总结一下:
确定的输入,一定会产生确定的输出;
函数在执行过程中,不能产生副作用;
副作用概念的理解
那么这里又有一个概念,叫做副作用,什么又是副作用呢?
*副作用(side effect)*其实本身是医学的一个概念,比如我们经常说吃什么药本来是为了治病,可能会产生一些其他的副作用;
在计算机科学中,也引用了副作用的概念,表示在执行一个函数时,除了返回函数值之外,还对调用函数产生了附加的影响,比如修改了全局变量,修改参数或者改变外部的存储;
纯函数在执行的过程中就是不能产生这样的副作用:
- 副作用往往是产生 bug 的 “温床”。
纯函数的案例
我们来看一个对数组操作的两个函数:
slice:slice 截取数组时不会对原数组进行任何操作,而是生成一个新的数组;
splice:splice 截取数组, 会返回一个新的数组, 也会对原数组进行修改;
slice 就是一个纯函数,不会修改数组本身,而 splice 函数不是一个纯函数;
判断下面函数是否是纯函数?
纯函数的作用和优势
作用:
为什么纯函数在函数式编程中非常重要呢?
因为你可以安心的编写和安心的使用;
你在写的时候保证了函数的纯度,只是单纯实现自己的业务逻辑即可,不需要关心传入的内容是如何获得的或者依赖其他的外部变量是否已经发生了修改;
你在用的时候,你确定你的输入内容不会被任意篡改,并且自己确定的输入,一定会有确定的输出;
React中就要求我们无论是函数还是 class 声明一个组件,这个组件都必须像纯函数一样,保护它们的 props 不被修改:
柯里化
柯里化概念的理解
柯里化(Currying)也是属于函数式编程里面一个非常重要的概念。
是一种关于函数的高阶技术;
它不仅被用于 JavaScript,还被用于其他编程语言;
我们先来看一下维基百科的解释:
在计算机科学中,柯里化,又译为卡瑞化或加里化;
是把接收多个参数的函数,变成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数,而且返回结果的新函数的技术;
柯里化声称 “如果你固定某些参数,你将得到接受余下参数的一个函数”;
维基百科的解释非常的抽象,我们这里做一个总结:
- 只传递给函数一部分参数来调用它,让它返回一个函数去处理剩余的参数,这个过程就称之为柯里化;
柯里化是一种函数的转换,将一个函数从可调用的 f(a, b, c) 转换为可调用的 f(a)(b)(c)。
- 柯里化不会调用函数。它只是对函数进行转换。
柯里化的代码转换
那么柯里化到底是怎么样的表现呢?
1、普通函数转柯里化函数
2、柯里化函数的箭头函数写法
柯里化优势一-函数的职责单一
那么为什么需要有柯里化呢?
在函数式编程中,我们其实往往希望一个函数处理的问题尽可能的单一,而不是将一大堆的处理过程交给一个函数来处理;
那么我们是否就可以将每次传入的参数在单一的函数中进行处理,处理完后在下一个函数中再使用处理后的结果;
比如上面的案例我们进行一个修改:传入的函数需要分别被进行如下处理
第一个参数 + 2
第二个参数 * 2
第三个参数 ** 2
柯里化优势二-函数的参数复用
另外一个使用柯里化的场景是可以帮助我们可以复用参数逻辑:
makeAdder 函数要求我们传入一个 num(并且如果我们需要的话,可以在这里对 num 进行一些修改);
在之后使用返回的函数时,我们不需要再继续传入 num 了;
柯里化案例练习
这里我们在演示一个案例,需求是打印一些日志:
- 日志包括时间、类型、信息;
普通函数的实现方案如下:
柯里化高级 - 手写自动柯里化函数@
目前我们有将多个普通的函数,转成柯里化函数:
组合函数
组合函数概念的理解
*组合函数(Compose Function)*是在 JavaScript 开发过程中一种对函数的使用技巧、模式:
比如我们现在需要对某一个数据进行函数的调用,执行两个函数 fn1 和 fn2,这两个函数是依次执行的;
那么如果每次我们都需要进行两个函数的调用,操作上就会显得重复;
那么是否可以将这两个函数组合起来,自动依次调用呢?
这个过程就是对函数的组合,我们称之为组合函数;
手写组合函数@
刚才我们实现的 compose 函数比较简单
我们需要考虑更加复杂的情况:比如传入了更多的函数,在调用 compose 函数时,传入了更多的参数:
with、eval
with语句的使用
with语句扩展一个语句的作用域链。
不建议使用 with 语句,因为它可能是混淆错误和兼容性问题的根源。
eval函数
内建函数 eval 允许执行一个代码字符串。
eval 是一个特殊的函数,它可以将传入的字符串当做 JavaScript 代码来运行;
eval 会将最后一句执行语句的结果,作为返回值;
不建议在开发中使用 eval:
eval 代码的可读性非常的差(代码的可读性是高质量代码的重要原则);
eval 是一个字符串,那么有可能在执行的过程中被刻意篡改,那么可能会造成被攻击的风险;
eval 的执行必须经过 JavaScript 解释器,不能被 JavaScript 引擎优化;
严格模式
认识严格模式
JavaScript 历史的局限性:
长久以来,JavaScript 不断向前发展且并未带来任何兼容性问题;
新的特性被加入,旧的功能也没有改变,这么做有利于兼容旧代码;
但缺点是 JavaScript 创造者的任何错误或不完善的决定也将永远被保留在 JavaScript 语言中;
在 ECMAScript5 标准中,JavaScript 提出了*严格模式(Strict Mode)*的概念:
严格模式很好理解,是一种具有限制性的 JavaScript 模式,从而使代码隐式的脱离了 ”懒散(sloppy)模式“;
支持严格模式的浏览器在检测到代码中有严格模式时,会以更加严格的方式对代码进行检测和执行;
严格模式对正常的 JavaScript 语义进行了一些限制:
严格模式通过 抛出错误 来消除一些原有的静默(silent)错误;
严格模式让JS 引擎在执行代码时可以进行更多的优化(不需要对一些特殊的语法进行处理);
严格模式禁用了在ECMAScript 未来版本中可能会定义的一些语法;
开启严格模式
那么如何开启严格模式呢?严格模式支持粒度话迁移:
可以支持在js 文件中开启严格模式;
也支持对某一个函数开启严格模式;
严格模式通过在文件或者函数开头使用 use strict 来开启。
注意:
没有类似于 "no use strict" 这样的指令可以使程序返回默认模式。
现代 JavaScript 支持 “class” 和 “module” ,它们会自动启用 use strict;
严格模式限制
JavaScript 被设计为新手开发者更容易上手,所以有时候本来错误语法,被认为也是可以正常被解析的;但是这种方式可能给带来留下来安全隐患;在严格模式下,这种失误就会被当做错误,以便可以快速的发现和修正;
这里我们来说几个严格模式下的严格语法限制:
1、无法意外的创建全局变量
2、严格模式会使引起静默失败(silently fail,注:不报错也没有任何效果)的赋值操作抛出异常
3、严格模式下试图删除不可删除的属性
4、严格模式不允许函数参数有相同的名称
5、不允许 0 的八进制语法,要使用 0o
6、在严格模式下,不允许使用 with
7、在严格模式下,eval 不能为上层引用(创建)变量
8、严格模式下,this 绑定不会默认转成对象,也不会绑定 window,而是 undefined
手写apply、call、bind函数实现(原型后)
接下来我们来实现一下 apply、call、bind 函数:
- 注意:我们的实现是练习函数、this、调用关系,不会过度考虑一些边界情况